Skip to content

fix(tree): track chunkIndex on field entry so getField finds parent#27084

Open
brrichards wants to merge 5 commits intomicrosoft:mainfrom
brrichards:basicChunkCursor-fix
Open

fix(tree): track chunkIndex on field entry so getField finds parent#27084
brrichards wants to merge 5 commits intomicrosoft:mainfrom
brrichards:basicChunkCursor-fix

Conversation

@brrichards
Copy link
Copy Markdown
Contributor

@brrichards brrichards commented Apr 17, 2026

Summary

basicChunkCursor currently doesn't handle tracking the chunk index around multi-node chunks correctly. This problem shows up in chunkedForest, which extends this cursor.

Files changed

File Changes
basicChunk.ts Change location of when chunkIndex is pushed onto the stack and getField now uses chunkIndex to find the parent in siblingStack
basicChunk.spec.ts Added two unit tests that manually construct a field with a UniformChunk followed by a BasicChunk with a subfield

What surfaces the bug

                                                                                                  
  Root field                                                                     
  ├─ chunks[0]  UniformChunk  (nodes 0, 1)
  │    ├─ 10                                                                                             
  │    └─ 20
  └─ chunks[1]  BasicChunk    (node 2)                                                                   
       └─ subField                                                               
            └─ 42     
   
siblingStack when getField is called after navigating to subField: [ UniformChunk(10,20) , BasicChunk "Trailing" ]              
 

When a field contains multi-node chunks followed by a BasicChunk containing a subfield, entering that subfield with enterField will call getField which searches for the parent of this subfield in the siblingStack. Currently this uses the flat node index, which equals the count of all nodes in the field. The problem is that siblingStack contains the chunks and not the flat nodes. So when getField is called using an index based off the flat nodes it will incorrectly index into the siblings array on that stack. In the case above we would attempt to get the parent BasicChunk at index 2, but that would be oob.

Changes

chunkIndex

The proposed changes are to swap the use of a flat node index for getField and use chunkIndex. This was being tracked before, but was a stale reference for what this fix requires. The flow for the current chunk index is:
start at 0 -> increment as we change chunks -> enterField(subField) -> getField -> enterNode/firstNode -> push chunkIndex onto the stack
This causes chunkIndex to not be on the stack when we need it. We would be looking at a stale reference for 1 level up. The change needed was pushing chunkIndex onto the stack during enterField/firstField so we have the needed chunkIndex on the stack when getField needs it.

halfHeight

halfHeight balances indexOfChunkStack.length against siblingStack.length. The current code pushed once per node level. The new code pushes once per Field level, so the rounding isn't needed like before. The assertion has been moved to its own private method assertChunkStacksMatchNodeDepth() since this is what the assertion was originally doing. Checking that the chunk stack indices were half the height of sibling stack, which would also correctly match the node depth.

New Test Coverage

Two unit tests in basicChunk.spec.ts that manually create a multi-node uniform chunk(not currently possible by the chunker) and then navigating into the subfield of a trailing basic chunk throws under the old indexing logic.

@brrichards brrichards marked this pull request as ready for review April 17, 2026 20:03
@brrichards brrichards requested a review from a team as a code owner April 17, 2026 20:03
Copilot AI review requested due to automatic review settings April 17, 2026 20:04
@brrichards brrichards changed the title fix(basicChunk): Correct basicChunkCursor indexing of sibling stack during getField fix(basicChunk): track chunk-array index on sibling stack for cursor getField Apr 17, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes BasicChunkCursor parent lookup during getField() when a field contains multi-node chunks (e.g., UniformChunk) followed by a BasicChunk, by tracking/restoring the correct chunk-array index instead of relying on the flat logical node index. This addresses failures observed in chunkedForest, which extends this cursor.

Changes:

  • Adjust BasicChunkCursor stack bookkeeping so the chunk-array position (indexOfChunk) is pushed/popped at the right times and used to resolve the correct parent chunk for getField().
  • Update node resolution (getNode()) to use indexOfChunk (chunk array index) rather than the flat node index.
  • Add unit tests that construct root fields with multi-node UniformChunks followed by a trailing BasicChunk containing a subfield, validating navigation doesn’t throw.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
packages/dds/tree/src/feature-libraries/chunked-forest/basicChunk.ts Fixes stack/index invariants and uses chunk-array indices when resolving the parent chunk for getField() (supports fields containing multi-node chunks).
packages/dds/tree/src/test/feature-libraries/chunked-forest/basicChunk.spec.ts Adds regression tests covering getField() parent resolution when preceded by one or more multi-node chunks.

@brrichards brrichards changed the title fix(basicChunk): track chunk-array index on sibling stack for cursor getField fix(tree): track chunkIndex on field entry so getField finds parent Apr 17, 2026
private getStackedChunkIndex(height: number): number {
assert(height % 2 === 1, "must be node height");
assert(height >= 0, "must not be above root");
// eslint-disable-next-line no-bitwise
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When suppressing the linter, its a good idea to document why its ok to suppress it in this case, like we do in https://github.com/microsoft/FluidFramework/pull/27084/changes#diff-e637d8b2f919c742dad1d0d98d18b101019ade1f3b748623aed966ba4b1119d7R169-R170

That said, we should probably deduplicate that code with this.

Introducing a getNodeOnlyHeightFromHeight method, which takes an optional height defaulting to this.siblingStack.length seems like it would be a good way to deduplicate this logic, and make it a bit more clear what is going on in it.

Comment on lines 169 to 180
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All of this seems like its just checking some invariants about the state of this cursor.

Randomly throwing invariant validation code in the middle of some method like this isn't a good pattern (I assume it was me who did this btw).

Factoring this out into something like an "assertValid" method, then calling it from a few key places might be a better approach.

You could probably then add a couple more checks which detect the kinds of bugs you found and are fixing here to help reduce the risk of regressions even more.

Comment thread packages/dds/tree/src/test/feature-libraries/chunked-forest/basicChunk.spec.ts Outdated
Comment thread packages/dds/tree/src/test/feature-libraries/chunked-forest/basicChunk.spec.ts Outdated
Comment thread packages/dds/tree/src/test/feature-libraries/chunked-forest/basicChunk.spec.ts Outdated
@@ -169,7 +169,7 @@ export class BasicChunkCursor extends SynchronousCursor implements ChunkedCursor
// Compute the number of nodes deep the current depth is.
// We want the floor of the result, which can computed using a bitwise shift assuming the depth is less than 2^31, which seems safe.
// eslint-disable-next-line no-bitwise
const halfHeight = (this.siblingStack.length + 1) >> 1;
const halfHeight = this.siblingStack.length >> 1;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was the old code wrong, but just never getting called in cases where that caused an assert before?

If so, https://github.com/microsoft/FluidFramework/pull/27084/changes#r3103181969 to make it easy to run this in more places seems extra valuable. I think that might have caught this bug.

Copy link
Copy Markdown
Contributor Author

@brrichards brrichards Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The old code was right for when we were pushing onto siblingStack and chunkStack The description was a little off since previously this was getting ceil(siblingStack.length/2) and not the floor. this.siblingStack.length + 1) gets the floor, but then "rounds up" by adding the 1. Since we changed when we push onto chunkStack we want the floor without rounding up, causing the equation change.

Old Stack Growth

Transition siblingStack.length chunkStack.length
fields (root) 0 0
After firstNode 1 1
After enterField 2 1
After firstNode 3 2

New Stack Growth

Transition siblingStack.length chunkStack.length
fields (root) 0 0
After firstNode 1 0
After enterField 2 1
After firstNode 3 1

@brrichards brrichards requested a review from a team April 20, 2026 18:30
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 20, 2026

🔭 PR Review Fleet Report

Note

This report is generated by an experimental AI review fleet and is provided as a beta feature. Findings are a starting point for discussion, not a gate. Use your own judgement.

Verdict: ⚠️ Approve with Suggestions

0 Spicy, 0 Pungent, 1 Smelly

Findings

Sev # Area File What Fix
🧅 Smelly M1 Testing packages/dds/tree/src/feature-libraries/chunked-forest/basicChunk.ts:505-521 exitField() now pops indexOfChunk/indexWithinChunk from indexOfChunkStack/indexWithinChunkStack to restore the cursor to a node that may have been reached via a multi-node chunk. Both new tests navigate into a field of such a node and validate the field's contents, but neither calls exitField() and then continues traversal (e.g., nextNode(), reading value, type, or getPath()). If the restored indexOfChunk is wrong (off-by-one or stale), getNode() which now uses this.indexOfChunk — would silently return the wrong BasicChunk on all subsequent reads, a bug that would go undetected in the current test suite. Concrete test to add: starting from

View workflow run

Comment thread packages/dds/tree/src/feature-libraries/chunked-forest/basicChunk.ts Outdated
Comment thread packages/dds/tree/src/feature-libraries/chunked-forest/basicChunk.ts Outdated
Comment thread packages/dds/tree/src/feature-libraries/chunked-forest/basicChunk.ts Outdated
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants